Routing & Edges
Unconditional Edges
The simplest edge — always traversed:
.From("researcher").To("writer")Fan-out and fan-in:
.From("triage").To("researcher", "factChecker") // parallel
.From("researcher", "factChecker").To("writer") // mergeConditional Edges
Add conditions after .To(). All conditions listed here are fully serializable — they round-trip through ExportConfigJson() and JSON config files.
Basic Field Conditions
// Field equals / not equals
.From("triage").To("writer").WhenEquals("complexity", "simple")
.From("triage").To("researcher").WhenEquals("complexity", "complex")
.From("checker").To("retry").WhenNotEquals("status", "ok")
// Field exists / not exists
.From("analyzer").To("escalate").WhenExists("risk_flag")
.From("analyzer").To("complete").WhenNotExists("risk_flag")
// Numeric comparisons
.From("scorer").To("approve").WhenGreaterThan("score", 0.8)
.From("scorer").To("review").WhenLessThan("score", 0.8)
// String / collection contains (single value)
.From("classifier").To("urgent").WhenContains("tags", "urgent")
// Default fallback (fires when no other condition from this node matched)
.From("triage").To("general").AsDefault()The field names refer to output keys from the source node (e.g., node.WithOutputKey("complexity")).
Compound Logic — And, Or, Not
Combine multiple conditions without dropping into a C# predicate. Use a static import for the cleanest syntax:
using static HPD.MultiAgent.Routing.Condition;// Both conditions must be true
.From("triage").To("vip-billing")
.When(And(
Equals("intent", "billing"),
Equals("tier", "VIP")
))
// Either condition must be true
.From("triage").To("escalate")
.When(Or(
Equals("status", "urgent"),
GreaterThan("priority", 8)
))
// Negate a condition
.From("classifier").To("skip")
.When(Not(Exists("summary")))
// Arbitrary nesting
.From("checker").To("approve")
.When(And(
Or(Equals("region", "US"), Equals("region", "EU")),
Not(Equals("flagged", true))
))And with an empty list returns true (vacuously). Or with an empty list returns false.
Constraint:
Defaultcannot appear insideAnd/Or/Not. It is a graph-level routing concept, not a boolean sub-condition. The evaluator throwsInvalidOperationExceptionif it is nested.
JSON representation:
{
"type": "And",
"conditions": [
{ "type": "FieldEquals", "field": "intent", "value": "billing" },
{ "type": "FieldEquals", "field": "tier", "value": "VIP" }
]
}Advanced String Conditions
// Starts / ends with
.From("router").To("billing").WhenStartsWith("intent", "billing/")
.From("router").To("billing").WhenEndsWith("code", "_billing")
// Regular expression
.From("classifier").To("affirm").WhenMatchesRegex("response", @"^(yes|sure|ok)$")
.From("classifier").To("affirm").WhenMatchesRegex("response", @"^yes$", RegexOptions.IgnoreCase)
// Empty / not empty (null, "", or whitespace all count as "empty")
.From("drafter").To("retry").WhenEmpty("draft")
.From("drafter").To("reviewer").WhenNotEmpty("draft")ReDoS protection: Regex evaluation has a 50 ms timeout by default. A match that exceeds it is treated as false (non-match) — the workflow continues safely. The timeout is configurable:
HPDAgent.Graph.Core.Orchestration.ConditionEvaluator.RegexMatchTimeout = TimeSpan.FromMilliseconds(100);Regex flags in JSON:
{ "type": "FieldMatchesRegex", "field": "intent", "value": "^billing", "regexOptions": "IgnoreCase" }
{ "type": "FieldMatchesRegex", "field": "body", "value": "^line1$", "regexOptions": "IgnoreCase,Multiline" }Multi-Value Collection Conditions
Use these when a node outputs an array of tags or steps (e.g., AgentOutputMode.Structured):
// At least one of the given values must be present in the field array
.From("classifier").To("escalate")
.WhenContainsAny("tags", "urgent", "escalate", "manager")
// All of the given values must be present in the field array
.From("checker").To("approve")
.WhenContainsAll("required_steps", "verified", "payment_ok")JSON:
{ "type": "FieldContainsAny", "field": "tags", "value": ["urgent", "escalate", "manager"] }
{ "type": "FieldContainsAll", "field": "required_steps", "value": ["verified", "payment_ok"] }These work correctly when the field value arrives as a JsonElement array (i.e., loaded from a JSON config file).
Non-Serializable Predicate Edges
For routing logic that genuinely cannot be expressed declaratively, a C# lambda escape hatch is available:
.From("scorer").To("approve").When(ctx => ctx.Get<double>("score") > 0.8 && ctx.HasKey("verified"))
.From("scorer").To("review").When(ctx => ctx.Get<double>("score") <= 0.8)⚠
.When(predicate)edges are not serializable — they cannot be round-tripped viaExportConfigJson()or stored in a JSON config file. Prefer the declarative conditions above for any workflow that needs serialization.
Condition Tier Summary
| Tier | API | Example | Serializable? |
|---|---|---|---|
| Basic | WhenEquals, WhenGreaterThan, WhenExists, WhenContains, … | Single-field checks | Yes |
| Advanced | When(And/Or/Not), WhenMatchesRegex, WhenContainsAny, WhenEmpty, … | Multi-field, patterns, arrays | Yes |
| Escape hatch | .When(predicate) | Arbitrary C# runtime logic | No |
Router Agents
A router agent uses handoffs — tool calls — to pick the next node, rather than relying on static field conditions. Useful when the routing logic requires LLM judgment.
AgentWorkflow.Create()
.AddRouterAgent("router", new AgentConfig
{
SystemInstructions = "Classify the request and route to the right team."
})
.WithHandoff("billing", "User has a billing or payment question")
.WithHandoff("technical", "User has a technical or product question")
.WithDefaultHandoff("general") // Fallback if no handoff is called
.AddAgent("billing", billingConfig)
.AddAgent("technical", techConfig)
.AddAgent("general", generalConfig)
.BuildAsync()The router gets a handoff_to_billing() and handoff_to_technical() tool. Whichever it calls determines the next node. WithDefaultHandoff sets a fallback edge for when no handoff is triggered.
You can also configure router options:
.AddRouterAgent("router", config)
.WithHandoff("billing", "Billing questions")
.Configure(node => node.WithTimeout(TimeSpan.FromSeconds(20)))Type-Based Routing
When a node uses UnionOutput, route based on which type was matched:
.AddAgent("classifier", classifierConfig, node =>
node.UnionOutput<SimpleAnswer, DetailedReport, EscalationRequest>())
.From("classifier").RouteByType()
.When<SimpleAnswer>("respond")
.When<DetailedReport>("format")
.When<EscalationRequest>("escalate")
.Default("respond") // fallbackThe matched_type output field (set automatically in Union mode) drives the routing.
Cyclic Graphs
Agents can loop back to earlier nodes for iterative refinement:
AgentWorkflow.Create()
.WithMaxIterations(5) // prevent infinite loops
.AddAgent("drafter", drafterConfig)
.AddAgent("reviewer", reviewerConfig)
.From("drafter").To("reviewer")
.From("reviewer").To("drafter").WhenEquals("verdict", "revise")
.From("reviewer").To("publisher").WhenEquals("verdict", "approved")
.BuildAsync()Set WithMaxIterations() on cyclic graphs. The workflow stops if the limit is reached.